/**
 * @license
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import '../../../styles/shared-styles.js';
import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-diff-selection_html.js';
import {DomUtilBehavior} from '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
import {GrRangeNormalizer} from '../gr-diff-highlight/gr-range-normalizer.js';
import {querySelectorAll} from '../../../utils/dom-util.js';

/**
 * Possible CSS classes indicating the state of selection. Dynamically added/
 * removed based on where the user clicks within the diff.
 */
const SelectionClass = {
  COMMENT: 'selected-comment',
  LEFT: 'selected-left',
  RIGHT: 'selected-right',
  BLAME: 'selected-blame',
};

const getNewCache = () => { return {left: null, right: null}; };

/**
 * @extends PolymerElement
 */
class GrDiffSelection extends mixinBehaviors( [
  DomUtilBehavior,
], GestureEventListeners(
    LegacyElementMixin(
        PolymerElement))) {
  static get template() { return htmlTemplate; }

  static get is() { return 'gr-diff-selection'; }

  static get properties() {
    return {
      diff: Object,
      /** @type {?Object} */
      _cachedDiffBuilder: Object,
      _linesCache: {
        type: Object,
        value: getNewCache(),
      },
    };
  }

  static get observers() {
    return [
      '_diffChanged(diff)',
    ];
  }

  /** @override */
  created() {
    super.created();
    this.addEventListener('copy',
        e => this._handleCopy(e));
    addListener(this, 'down',
        e => this._handleDown(e));
  }

  /** @override */
  attached() {
    super.attached();
    this.classList.add(SelectionClass.RIGHT);
  }

  get diffBuilder() {
    if (!this._cachedDiffBuilder) {
      this._cachedDiffBuilder =
          dom(this).querySelector('gr-diff-builder');
    }
    return this._cachedDiffBuilder;
  }

  _diffChanged() {
    this._linesCache = getNewCache();
  }

  _handleDownOnRangeComment(node) {
    if (node &&
        node.nodeName &&
        node.nodeName.toLowerCase() === 'gr-comment-thread') {
      this._setClasses([
        SelectionClass.COMMENT,
        node.commentSide === 'left' ?
          SelectionClass.LEFT :
          SelectionClass.RIGHT,
      ]);
      return true;
    }
    return false;
  }

  _handleDown(e) {
    // Handle the down event on comment thread in Polymer 2
    const handled = this._handleDownOnRangeComment(e.target);
    if (handled) return;

    const lineEl = this.diffBuilder.getLineElByChild(e.target);
    const blameSelected = this._elementDescendedFromClass(e.target, 'blame');
    if (!lineEl && !blameSelected) { return; }

    const targetClasses = [];

    if (blameSelected) {
      targetClasses.push(SelectionClass.BLAME);
    } else {
      const commentSelected =
          this._elementDescendedFromClass(e.target, 'gr-comment');
      const side = this.diffBuilder.getSideByLineEl(lineEl);

      targetClasses.push(side === 'left' ?
        SelectionClass.LEFT :
        SelectionClass.RIGHT);

      if (commentSelected) {
        targetClasses.push(SelectionClass.COMMENT);
      }
    }

    this._setClasses(targetClasses);
  }

  /**
   * Set the provided list of classes on the element, to the exclusion of all
   * other SelectionClass values.
   *
   * @param {!Array<!string>} targetClasses
   */
  _setClasses(targetClasses) {
    // Remove any selection classes that do not belong.
    for (const key in SelectionClass) {
      if (SelectionClass.hasOwnProperty(key)) {
        const className = SelectionClass[key];
        if (!targetClasses.includes(className)) {
          this.classList.remove(SelectionClass[key]);
        }
      }
    }
    // Add new selection classes iff they are not already present.
    for (const _class of targetClasses) {
      if (!this.classList.contains(_class)) {
        this.classList.add(_class);
      }
    }
  }

  _getCopyEventTarget(e) {
    return dom(e).rootTarget;
  }

  /**
   * Utility function to determine whether an element is a descendant of
   * another element with the particular className.
   *
   * @param {!Element} element
   * @param {!string} className
   * @return {boolean}
   */
  _elementDescendedFromClass(element, className) {
    return this.descendedFromClass(element, className,
        this.diffBuilder.diffElement);
  }

  _handleCopy(e) {
    let commentSelected = false;
    const target = this._getCopyEventTarget(e);
    if (target.type === 'textarea') { return; }
    if (!this._elementDescendedFromClass(target, 'diff-row')) { return; }
    if (this.classList.contains(SelectionClass.COMMENT)) {
      commentSelected = true;
    }
    const lineEl = this.diffBuilder.getLineElByChild(target);
    if (!lineEl) {
      return;
    }
    const side = this.diffBuilder.getSideByLineEl(lineEl);
    const text = this._getSelectedText(side, commentSelected);
    if (text) {
      e.clipboardData.setData('Text', text);
      e.preventDefault();
    }
  }

  _getSelection() {
    const diffHosts = querySelectorAll(document.body, 'gr-diff');
    if (!diffHosts.length) return window.getSelection();

    const curDiffHost = diffHosts.find(diffHost => {
      if (!diffHost || !diffHost.shadowRoot) return false;
      const selection = diffHost.shadowRoot.getSelection();
      // Pick the one with valid selection:
      // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
      return selection && selection.type !== 'None';
    });

    return curDiffHost ?
      curDiffHost.shadowRoot.getSelection(): window.getSelection();
  }

  /**
   * Get the text of the current selection. If commentSelected is
   * true, it returns only the text of comments within the selection.
   * Otherwise it returns the text of the selected diff region.
   *
   * @param {!string} side The side that is selected.
   * @param {boolean} commentSelected Whether or not a comment is selected.
   * @return {string} The selected text.
   */
  _getSelectedText(side, commentSelected) {
    const sel = this._getSelection();
    if (sel.rangeCount != 1) {
      return ''; // No multi-select support yet.
    }
    if (commentSelected) {
      return this._getCommentLines(sel, side);
    }
    const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
    const startLineEl =
        this.diffBuilder.getLineElByChild(range.startContainer);
    const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
    // Happens when triple click in side-by-side mode with other side empty.
    const endsAtOtherEmptySide = !endLineEl &&
        range.endOffset === 0 &&
        range.endContainer.nodeName === 'TD' &&
        (range.endContainer.classList.contains('left') ||
         range.endContainer.classList.contains('right'));
    const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
    let endLineNum;
    if (endsAtOtherEmptySide) {
      endLineNum = startLineNum + 1;
    } else if (endLineEl) {
      endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
    }

    return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
        range.endOffset, side);
  }

  /**
   * Query the diff object for the selected lines.
   *
   * @param {number} startLineNum
   * @param {number} startOffset
   * @param {number|undefined} endLineNum Use undefined to get the range
   *     extending to the end of the file.
   * @param {number} endOffset
   * @param {!string} side The side that is currently selected.
   * @return {string} The selected diff text.
   */
  _getRangeFromDiff(startLineNum, startOffset, endLineNum, endOffset, side) {
    const lines =
        this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
    if (lines.length) {
      lines[lines.length - 1] = lines[lines.length - 1]
          .substring(0, endOffset);
      lines[0] = lines[0].substring(startOffset);
    }
    return lines.join('\n');
  }

  /**
   * Query the diff object for the lines from a particular side.
   *
   * @param {!string} side The side that is currently selected.
   * @return {!Array<string>} An array of strings indexed by line number.
   */
  _getDiffLines(side) {
    if (this._linesCache[side]) {
      return this._linesCache[side];
    }
    let lines = [];
    const key = side === 'left' ? 'a' : 'b';
    for (const chunk of this.diff.content) {
      if (chunk.ab) {
        lines = lines.concat(chunk.ab);
      } else if (chunk[key]) {
        lines = lines.concat(chunk[key]);
      }
    }
    this._linesCache[side] = lines;
    return lines;
  }

  /**
   * Query the diffElement for comments and check whether they lie inside the
   * selection range.
   *
   * @param {!Selection} sel The selection of the window.
   * @param {!string} side The side that is currently selected.
   * @return {string} The selected comment text.
   */
  _getCommentLines(sel, side) {
    const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
    const content = [];
    // Query the diffElement for comments.
    const messages = this.diffBuilder.diffElement.querySelectorAll(
        `.side-by-side [data-side="${side
        }"] .message *, .unified .message *`);

    for (let i = 0; i < messages.length; i++) {
      const el = messages[i];
      // Check if the comment element exists inside the selection.
      if (sel.containsNode(el, true)) {
        // Padded elements require newlines for accurate spacing.
        if (el.parentElement.id === 'container' ||
            el.parentElement.nodeName === 'BLOCKQUOTE') {
          if (content.length && content[content.length - 1] !== '') {
            content.push('');
          }
        }

        if (el.id === 'output' &&
            !this._elementDescendedFromClass(el, 'collapsed')) {
          content.push(this._getTextContentForRange(el, sel, range));
        }
      }
    }

    return content.join('\n');
  }

  /**
   * Given a DOM node, a selection, and a selection range, recursively get all
   * of the text content within that selection.
   * Using a domNode that isn't in the selection returns an empty string.
   *
   * @param {!Node} domNode The root DOM node.
   * @param {!Selection} sel The selection.
   * @param {!Range} range The normalized selection range.
   * @return {string} The text within the selection.
   */
  _getTextContentForRange(domNode, sel, range) {
    if (!sel.containsNode(domNode, true)) { return ''; }

    let text = '';
    if (domNode instanceof Text) {
      text = domNode.textContent;
      if (domNode === range.endContainer) {
        text = text.substring(0, range.endOffset);
      }
      if (domNode === range.startContainer) {
        text = text.substring(range.startOffset);
      }
    } else {
      for (const childNode of domNode.childNodes) {
        text += this._getTextContentForRange(childNode, sel, range);
      }
    }
    return text;
  }
}

customElements.define(GrDiffSelection.is, GrDiffSelection);
